﻿\subsection{基本功能函数库}

操作系统内核主要以 C 语言编写，为了提高操作系统内核模块的重用性，需要将频繁使用的功能模块抽离出来单独实现，并作为通用的函数库供内核调用。
这些通用的函数库可参考 C 标准库中提供的库函数进行设计，如 \lstinline{itoa}、\lstinline{putchar}、\lstinline{memset}、\lstinline{strlen}、\lstinline{strcmp} 等，
本节主要探讨实现这些基本功能函数涉及的若干技术细节。

\subsubsection{内联汇编}

操作系统是各类硬件资源的管理者，需要大量地使用体系结构提供的汇编指令完成某些 C 语言本身无法完成的任务。
C 语言与汇编语言混编最直接的方式是采用编译器扩展的内联汇编语法。
本项目使用的 LLVM-Clang 能够同时支持 GNU 风格和 MSVC 两种风格的内联汇编，考虑到与 NASM 的 intel 汇编语法风格保持一致，本项目中采用了 MSVC 内联汇编样式，使用 LLVM-Clang 编译时需要指定 \lstinline{-fasm-blocks} 命令行选项。
使用 MSVC 内联汇编时，对 \lstinline{ESP} 和 \lstinline{EBP} 以外的的通用寄存器进行写操作时将由编译器视情况自动保存。
此外，MSVC 内联汇编采用 MASM 语法，与 NASM 的汇编语法稍有区别，具体细节可参考 NASM 汇编文档和微软 MSVC 内联汇编文档 \cite{msvc-inline-asm}。

鉴于读写端口等汇编指令使用较为频繁，可以采用 C 语言内联函数结合内联汇编进行实现，这样，就可以以函数调用的形式使用内联汇编语句块。
例如，下面的内联函数可向端口写入一个字节的数据。

\begin{minted}[breaklines, breakautoindent=true]{cpp}
static inline void __outbyte(WORD wPort, BYTE bData)
{
    __asm
    {
        mov dx, wPort
        mov al, bData
        out dx, al
    }
}
\end{minted}

内联汇编是 C 语言与汇编语言混合编程的一种方案，在汇编语句块分布较为集中、对代码可移植性要求不高、没有特殊要求且对编译优化影响不大的场合中是不错的方案。

\subsubsection{二进制应用接口规范}

内联汇编镶嵌在 C 语言上下文中，C 语言对内存栈帧的操控是 C 语言代码编写者不可见的，当需要精确操控内存栈帧时（如中断现场的保存和恢复），整个功能模块必须以纯粹的汇编语言实现，这时就需要考虑 C 语言模块和汇编语言模块之间如何相互调用的问题，即 \textbf{调用约定（Calling Convention）}。调用约定从概念上从属于另一个更宽泛的概念——应用程序二进制接口（Application Binary Interface）。
不论是汇编语言还是 C 语言这样的高级语言，在二进制层面上都是兼容的。
CPU 最后解释执行的总是二进制的机器指令。
只要汇编程序和高级语言编写的程序都遵循同样的调用约定，就可以保证最后链接形成的二进制文件能够在 CPU 上正确解释执行。
x86 体系结构下，使用较多的是标准调用约定（或称 C 调用约定）。
32 位环境下的 C 调用约定在 System V Application Binary Interface Intel386 Architecture Processor Supplement\cite{systemv-i386}
规范中定义。
进行函数调用时，所有的参数都以自右向左的顺序压栈，调用方 \lstinline{Caller} 通过 \lstinline{call} 指令发起函数调用，CPU 会将返回地址（即 \lstinline{call} 指令下一条指令的地址压入栈顶）。
按照约定，若不考虑编译优化，被调用方 \lstinline{Callee} 进行一系列开辟堆栈框架的流程：开辟栈帧；执行结束后清理当前栈帧；将 32 位的返回值保存在 \lstinline{EAX} 中；64 位的返回值分两部分分别保存在 \lstinline{EDX}（高 32 位） \lstinline{EAX}（低 32 位）中；通过 \lstinline{ret} 指令返回。
在函数返回后，由调用方负责清理压栈的参数。

为提高 CPU 执行指令的效率，在实际的操作系统中常要求栈帧按照一定的方式边界对齐（Border Alignment），以便 CPU 一次从内存中取出一条完整的数据。
不同的体系结构下的操作系统对于边界对齐有不同的要求。
例如，SSE 扩展指令集中要求数据按照 16 字节对齐以确保 SSE 指令的执行效率，相应地，64 位 Windows 和 Linux 操作系统中都要求函数调用时栈帧遵循 16 字节边界对齐规则。
编写 C 语言程序时，边界对齐由编译器保证，在编写汇编程序时则需要额外考虑边界对齐。

本项目开发的操作系统工作于 32 位环境下，采用 4 字节边界对齐的方式。
上述代码中，\lstinline{Caller} 通过 \lstinline{call} 发起函数调用时，需要确保 \lstinline{esp} 按照 4 字节边界对齐，即 \lstinline{esp} $mod 4 = 0$，这样可确保 \lstinline{Callee} 从栈中一次性读出的 4 字节包含了一条完整的数据（除非是 \lstinline{QWORD} 这样占用 8 字节空间的参数，需要两次访存才能取得完整数据）。
边界对齐还有额外的好处，由于栈中的每个参数大小都是 4 字节的整数倍，故可以利用这一特性实现可变参数（Variable Parameters），即函数的参数个数可以是不确定的，C 标准库中的 \lstinline{printf} 就是一例。

\begin{minted}{nasm}
Caller:
    push param_1 ; 4 字节的参数大小
    ; ...
    push param_n ; 4 字节的参数大小
    call Callee ; 调用方需保证 esp 按 4 字节边界对齐
    add esp, 4 * n ; 调用方清栈
    next_instruction
    ; ...    
Callee:
    ; 堆栈框架指令（创建栈帧）
    push ebp ; 保存 ebp
    mov ebp, esp
    sub esp, N ; 为局部变量开辟 N （N mod 4 == 0） 字节的内存空间
    ; 函数体
    ; 堆栈框架指令（回收栈帧）
    mov esp, ebp ; 恢复 esp 到开辟局部变量空间前的状态
    pop ebp ; 弹出 ebp
    ret ; 弹出返回地址，下条指令为 add esp, 4 * n
\end{minted}

上述代码中 \lstinline{Caller} 执行的函数调用前的压栈（被称作 prolog）、返回后的清栈（被称作 epilog）操作以及 \lstinline{Callee} 执行的堆栈框架（创建、回收栈帧）指令，都会带来额外的开销。
除了 C 调用约定，C 语言编译器还支持很多其他的调用约定。
本项目中除了 C 调用约定外，还使用另一种名为 fastcall 的调用约定 \cite{msdocs-cpp}。
fastcall 调用约定同样针对 32 位 x86 处理器，该调用约定与 C 调用约定的区别在于传递参数时前两个参数依次保存在 \lstinline{ECX} 和 \lstinline{EDX} 寄存器中，其余参数在栈上逆序推送 \footnote{ 对于可变参数的场景，fastcall 调用约定退化为 C 调用约定。
}，对于参数很少却使用频繁的函数而言，fastcall 是效率很高的调用约定。
现代 C 语言编译器一般都支持 fastcall 调用约定，在 C 语言源代码中，可使用 \lstinline{__fastcall} 声明使用 fastcall 调用约定的函数。

\begin{minted}[breakautoindent=true, breaklines]{cpp}
type __fastcall function([type1 param1, type2 param2, type3 param3, ...]);
\end{minted}

\subsubsection{显示设备的使用和封装}

在内核开发中，需要频繁地使用到显示设备打印字符文本。
字符打印涉及到对显示适配器及显存的操作，以下对 VGA 显示适配器做简单介绍。

VGA 显示适配器可工作于三种模式：文本模式、黑白模式、彩色模式。

文本模式对应于实模式 \MiB{1} 内存布局下的 \lstinline{0xB'8000} \TO \lstinline{0xB'FFFF} 地址范围。
VGA 文本模式下，一页屏幕上每行最多可显示 80 个字符，每个字符由 8 位的字符属性和 8 位的 ASCII 编码构成，一屏共包含 25 个这样的行，故 VGA 文本模式又称 8025 模式。
8025 模式下共有 32768 字节，一屏显示的字符占 4000 字节，因此，8025 模式专用的显存中可保存 8 页这样的屏幕，编号为 0 \TO 7，也即上表中 \lstinline{BH} 中存放的显示屏页号。
字符属性用于控制字符的前景色和背景色，具体的配置方式见表 \ref{CHAR_ATTRIBUTES}。

\begin{figure}[h!]
    \captionsetup{type=table}
    \caption{字符属性}
    \label{CHAR_ATTRIBUTES}
    \Centering
    \includegraphics[scale=0.75]{build/Paper/Assets/Char-Attributes.pdf}
\end{figure}

显存中每个字符占用一个字的大小，连接到 VGA 显存的数据总线宽度为一个字。
可使用 \lstinline{mov} 指令直接操作显存，一次最多向显存中写入一个字大小的数据。
显存中保存的字符低字节存放字符的 ASCII 编码，高字节存放字符属性。
可以通过直接向显存写入字符的方式让显卡在屏幕上输出字符，在实模式下也可以调用 BIOS 中断做到这一点。
BIOS 中断添加了对诸如回车字符（Carriage Return，CR）和换行字符（Line Feed，LF）等控制字符的支持，且会在打印字符后调整光标寄存器的值。
所谓光标寄存器，就是指示屏幕上闪烁的光标位置的寄存器。
在打印一个字符后，BIOS 中断服务程序会将光标寄存器后移一个字符单位，使下次打印的字符能够存放到正确位置。
在进入保护模式后，BIOS 中断服务程序不再可用，因此需要由开发人员在打印字符前手动获取光标寄存器位置，将字符写入光标所指示的位置后，再将光标后移一个字符的位置。

BIOS 提供了 \lstinline{0x10} 号中断 （表 \ref{BIOS_PRINT_CHAR}），该中断服务程序封装了 VGA 显示适配器的功能。
其 \lstinline{0x09} 号子功能可在文本模式下打印带属性的 ASCII 字符。

\begin{figure}[h!]
\captionsetup{type=table}
\caption{BIOS 中断——打印字符}
\label{BIOS_PRINT_CHAR}
\begin{minipage}{14cm}
    \Centering
    \begin{tabular}{|>{\RaggedRight\arraybackslash\small}p{10cm}|}
        \hline
        \Centering\textbf{BIOS 中断——打印字符}\\
        \hline
        描述：向显存写入带属性的字符 \\
        中断号：\verb{0x10}\\
        功能号：\verb{0x09}\\
        \hline
        \textbf{输入}\\
        \quad{}\verb{AH} = \verb{0x09}\\
        \quad{}\verb{BH} = 显示屏页号 \\
        \quad{}\verb{CX} = 连续写入几次该字符 \\
        \quad{}\verb{AL} = 字符的 ASCII 编码 \\
        \quad{}\verb{BL} = 字符属性 \\
        \hline
        \textbf{输出}\\
        \quad{} 无 \\
        \hline
    \end{tabular}
\end{minipage}
\end{figure}

在主引导记录中编写一些日志信息，将主引导记录使用 \verb{dd} 工具写入磁盘，使用 Bochs 运行后效果如图 \ref{MBR_LOG}。

\begin{figure}[h!]
  \Centering
  \caption{主引导记录打印日志信息}
  \label{MBR_LOG}
  \includegraphics[scale=0.8]{build/Paper/Assets/MBR-Print-Logs.png}
\end{figure}

进入 32 位保护模式后，为继续使用字符打印功能，需要重新对 VGA 显示适配器的功能进行封装。

对显卡等外围设备的控制是通过处理器读写端口完成的，VGA 显卡中提供了大量的寄存器，鉴于处理器端口的数量有限，VGA 显卡采用了另一种思路控制这些寄存器。
VGA 显卡中设置了地址寄存器和数据寄存器这两个特殊的寄存器，当需要控制某个功能寄存器时，
处理器通过一条写端口指令在地址寄存器中设置需要操作的寄存器地址，再通过一条写端口指令在数据寄存器中写入数据，或者通过一条读端口指令从数据寄存器中读出数据。
这样，只需要占用两个处理器端口，就可实现对显卡内所有寄存器的访问。

8025 模式下，字符的打印位置由光标地址指示。
一个光标的地址需要占用一个字的大小，这一个字分两个字节分别存放在两个寄存器中 —— Cursor Location High Register 和 Cursor Location Low Register，分别对应高字节和低字节。
读取和调整光标位置本质上就是对这两个光标寄存器的读写操作。
在打印单个普通字符前，通过获取光标寄存器的值，将字符写入光标指示的位置后，更新光标寄存器。

在引导加载器中开启分页模式时，已经将文本模式专用的显存映射到虚拟地址范围 \lstinline{0xC001'A000} \TO \lstinline{0xC002'1FFF}（表 \ref{REAL_MODE_1MiB_MAPPING}）。
文本模式下打印字符本质上就是向显存中写入字符的 ASCII 编码，但这只对可打印的 ASCII 字符成立。
对于 ASCII 编码落在 \lstinline{0x00} \TO \lstinline{0x1F} 范围内的控制字符，需要由软件层面决定应该如何解释。
例如，对于 \lstinline{'\r'} 字符对应的操作是将光标从当前位置移动到行首，\lstinline{'\n'} 字符对应的操作是将光标从当前行移动到下一行。
正确解释这两个字符后，由 \lstinline{"\r\n"} 字符串就可实现文本的换行。

有了基本的字符打印函数，不难实现的纯字符串打印函数。
格式化字符串打印函数是在纯字符串打印的基础上实现的。除此之外，使用到了之前提及的 \lstinline{itoa} 和 \lstinline{utoa} 用以解析数字转义序列。
格式化字符串打印函数接受可变参数，由于函数遵循 C 标准调用约定，因此这些参数都会按照自右向左的顺序依次压入栈中，每个参数都按照 4 字节边界对齐。
因此，通过第一个参数的地址即可确定后续所有可变参数的地址，可变参数的个数根据格式化字符串 \lstinline{lpszFmt} 的内容确定。
根据格式化字符串中的转义序列依次解析参数，即在纯字符串打印的基础上可实现格式化字符串打印的功能。
